概述
本节讲解课程详情页中章节目录(Chapters)和学员评价(Comments)两个子组件的实现。核心内容包括 Flex 与 Grid 布局在 Tabs 切换时的稳定性差异,以及使用 provide / inject 依赖注入在嵌套路由中传递数据。
1. 布局抖动问题与解决
1.1 问题现象
当用户在"课程介绍"、"章节目录"、"学员评价"三个 Tabs 之间切换时,左侧内容区和右侧边栏之间的间距会发生变化(布局抖动)。
原因:不同 Tab 内容的宽度不一致,在使用 Flex 布局时,内容区会根据实际内容宽度自动调整,导致布局不稳定。
1.2 Flex 布局解决方案
使用 calc() 将宽度固定:
<div class="flex">
<!-- 左侧内容区:固定宽度 -->
<div class="w-[calc(75%-1rem)] mr-4">
<!-- Tabs 内容 -->
</div>
<!-- 右侧边栏 -->
<div class="w-1/4">
<!-- 推荐课程 -->
</div>
</div>
vue
calc(75% - 1rem) 减去 mr-4(1rem)的间距,确保内容区宽度不会因内容变化而抖动。
1.3 Grid 布局解决方案
Grid 布局天然具有固定的轨道尺寸,不会出现抖动问题:
<div class="grid grid-cols-4 gap-4">
<!-- 左侧:占 3 列 -->
<div class="col-span-3">
<!-- Tabs 内容 -->
</div>
<!-- 右侧:占 1 列 -->
<div class="col-span-1">
<!-- 推荐课程 -->
</div>
</div>
vue
| 布局方案 | 优点 | 缺点 |
|---|---|---|
| Flex + calc | 灵活的百分比控制 | 需要手动计算间距 |
| Grid | 切换稳定,无需计算 | 列宽比例不够灵活 |
推荐:需要精确百分比控制时使用 Flex,需要稳定性时使用 Grid。
2. 章节目录组件(Chapters)
2.1 组件结构
<script setup lang="ts" generic="T">
interface ChapterItem {
id: number
title: string
duration: string
free: boolean
}
defineProps<{
items: ChapterItem[]
}>()
</script>
<template>
<div class="divide-y">
<div
v-for="chapter in items"
:key="chapter.id"
class="flex items-center py-3"
>
<span class="iconify mr-2" data-icon="ep:video-play" />
<span class="flex-1">{{ chapter.title }}</span>
<span class="text-sm text-gray-500">{{ chapter.duration }}</span>
<span
v-if="chapter.free"
class="ml-2 text-xs text-sky-400 border border-sky-400 rounded px-1"
>
试看
</span>
</div>
</div>
</template>
vue
2.2 泛型扩展
使用泛型 generic="T" 扩展基础属性,使组件可以接受带有额外字段的数据对象:
// 基础接口
interface BaseItem {
id: number
title: string
}
// 使用泛型 + extends 约束
<script setup lang="ts" generic="T extends BaseItem">
defineProps<{ items: T[] }>()
</script>
ts
TypeScript 会自动推导 T 的具体类型,组件内可以安全访问 id 和 title,同时保留额外的业务字段。
3. 学员评价组件(Comments)
<script setup lang="ts" generic="T">
interface CommentItem {
id: number
user: string
avatar: string
content: string
time: string
rating: number
}
defineProps<{
items: CommentItem[]
}>()
</script>
<template>
<div class="space-y-4">
<div
v-for="comment in items"
:key="comment.id"
class="flex gap-3"
>
<img :src="comment.avatar" class="w-10 h-10 rounded-full" />
<div class="flex-1">
<div class="flex justify-between">
<span class="font-medium">{{ comment.user }}</span>
<span class="text-sm text-gray-400">{{ comment.time }}</span>
</div>
<p class="mt-1 text-gray-700">{{ comment.content }}</p>
</div>
</div>
</div>
</template>
vue
4. 依赖注入:provide / inject
4.1 为什么使用依赖注入
在嵌套路由中,activeIndex(当前激活的 Tab 索引)在详情页外层 [id].vue 中维护,但章节目录和评价组件位于内层 index.vue 中。通过 provide / inject 可以跨越组件层级传递数据,避免逐层 props 传递。
4.2 在父组件中提供数据
<!-- [id].vue -->
<script setup lang="ts">
const activeIndex = ref(0)
provide('activeIndex', activeIndex)
</script>
vue
4.3 在子组件中注入数据
<!-- study/[id]/index.vue -->
<script setup lang="ts">
const activeIndex = inject<Ref<number>>('activeIndex', ref(0))
</script>
<template>
<div>
<!-- 课程介绍 -->
<CourseIntro v-if="activeIndex === 0" />
<!-- 章节目录 -->
<Chapters v-else-if="activeIndex === 1" :items="chapters" />
<!-- 学员评价 -->
<Comments v-else-if="activeIndex === 2" :items="comments" />
</div>
</template>
vue
4.4 provide/inject vs props 传参
| 特性 | provide/inject | props |
|---|---|---|
| 跨层级传递 | 支持(任意深度) | 不支持(需逐层传递) |
| 类型安全 | 需手动指定泛型 | 自动推导 |
| 调试可追踪性 | 较弱 | 较强 |
| 适用场景 | 深层嵌套、主题/配置共享 | 父子直接通信 |
最佳实践:对于跨越 2 层以上的数据传递,使用 provide / inject;对于直接的父子关系,使用 props。
5. Mock 数据说明
当前章节和评价数据均为页面内的 Mock 数据,后续将替换为接口数据:
// Mock 章节数据
const chapters = ref([
{ id: 1, title: '课程导读', duration: '12:30', free: true },
{ id: 2, title: '环境搭建', duration: '25:00', free: false },
// ...
])
// Mock 评价数据
const comments = ref([
{ id: 1, user: '张同学', avatar: '/avatar.png', content: '课程很好!', time: '2024-01-15', rating: 5 },
// ...
])
ts
小结
| 要点 | 说明 |
|---|---|
| 布局稳定性 | Grid 布局天然稳定,Flex 需用 calc() 固定宽度 |
| 泛型组件 | generic="T" 使 Chapters / Comments 支持多种数据类型 |
| provide / inject | 跨路由层级传递 activeIndex,避免逐层 props |
| Mock 数据 | 当前使用本地数据,后续对接接口替换 |
↑